SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
7.4 KB · 161 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { t } from '@/i18n';5import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';6import { routes } from '@/lib/site';7import { regionShort } from '@/lib/regions';8import { TOPICS, isTopicId, topicById } from '@/lib/topics';9import type { CountryTopicResponse, MetricValue, SeriesResponse } from '@/lib/types';10import { IndicatorRow } from '@/components/country/indicator-row';11import { TopicCompact } from '@/components/country/topic-compact';12import { subtopicAnchor } from '@/lib/anchors';13import { CollapsibleGroup, SubtopicJumpNav } from '@/components/data/collapsible-group';14import { NotBuiltState } from '@/components/data/empty-state';15import { Section } from '@/components/data/section';16import { TopicNav } from '@/components/data/topic-nav';1718export const revalidate = 900;1920type Params = { slug: string; topic: string };21const EAGER = 4; // charts rendered with server-fetched full history; the rest mount on scroll22const SECTION_THRESHOLD = 12; // above this many indicators with data → collapsible subtopic blocks23const OPEN_BLOCKS = 2; // blocks expanded by default on a sectioned topic page2425async function load(slug: string, topic: string): Promise<CountryTopicResponse | 'not-built' | null> {26  if (!isTopicId(topic)) return null;27  try {28    return await api.countryTopic(slug, topic);29  } catch (e) {30    if (isNotFound(e)) return null;31    if (isNotBuilt(e)) return 'not-built';32    throw e;33  }34}3536export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {37  const { slug, topic } = await params;38  const data = await load(slug, topic);39  if (!data || data === 'not-built') return { title: t('topic.notFound'), robots: { index: false } };40  const country = data.country.name ?? slug;41  const list = data.subtopics42    .flatMap((b) => b.indicators)43    .slice(0, 4)44    .map((m) => (m.indicator_name ?? m.indicator).toLowerCase())45    .join(', ');46  const title = t('topic.title', { country, topic: data.topic.name });47  const canonical = routes.countryTopic(data.country.slug ?? slug, topic);48  return {49    title,50    description: t('topic.description', { topic: data.topic.name, country, list }),51    alternates: { canonical },52    openGraph: { title: `${title} — ${t('site.name')}`, url: canonical, type: 'article' },53  };54}5556export default async function CountryTopicPage({ params }: { params: Promise<Params> }) {57  const { slug, topic } = await params;58  const data = await load(slug, topic);59  if (data === null) notFound();60  if (data === 'not-built') return <NotBuiltState />;6162  const c = data.country;63  const countryRef = { id: c.id, slug: c.slug ?? slug, name: c.name ?? c.id, flag: c.flag };64  const all = data.subtopics.flatMap((b) => b.indicators);65  const withData = all.filter((m) => m.has_data);66  const noData = all.filter((m) => !m.has_data);67  const blocks = data.subtopics.map((b) => ({ ...b, rows: b.indicators.filter((m) => m.has_data) })).filter((b) => b.rows.length > 0);68  // Long topics (> 12 indicators with data, ≥ 2 subtopics): collapsible subtopic blocks, first two expanded.69  const sectioned = withData.length > SECTION_THRESHOLD && blocks.length >= 2;70  const openBlocks = sectioned ? blocks.slice(0, OPEN_BLOCKS) : blocks;71  const eagerIds = openBlocks72    .flatMap((b) => b.rows)73    .slice(0, EAGER)74    .map((m) => m.indicator);75  const eagerSeries = await Promise.all(eagerIds.map((iid) => safe(api.countrySeries(c.id, iid))));76  const seriesById = new Map<string, SeriesResponse | null>(eagerIds.map((iid, i) => [iid, eagerSeries[i] ?? null]));77  const def = topicById(topic);78  const others = TOPICS.filter((tp) => tp.id !== topic);7980  return (81    <>82      <header className="pb-3 pt-6 md:pt-10">83        <nav aria-label="Breadcrumb" className="text-xs text-ink-3">84          <Link href={routes.countries()} className="hover:text-accent">85            {t('nav.countries')}86          </Link>87          <span className="mx-1.5">/</span>88          <Link href={routes.country(countryRef.slug)} className="hover:text-accent">89            <span aria-hidden>{c.flag} </span>90            {countryRef.name}91          </Link>92        </nav>93        <h1 className="display mt-2 text-3xl leading-tight text-ink md:text-4xl">94          {countryRef.name} <span className="text-ink-3">·</span> {data.topic.name}95        </h1>96        <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{data.topic.blurb ?? def?.blurb}</p>97        <p className="tnum mt-1 text-xs text-ink-3">98          {t('topic.indicators', { n: data.n_indicators })} · {t('topic.withData', { n: data.n_with_data })}99        </p>100      </header>101      <TopicNav slug={countryRef.slug} />102103      {sectioned ? <SubtopicJumpNav items={blocks.map((b) => ({ id: subtopicAnchor('sub', b.subtopic), label: b.subtopic, count: b.rows.length }))} label={t('topic.jump')} className="mt-3" /> : null}104105      {blocks.map((block, i) => {106        const id = subtopicAnchor('sub', block.subtopic);107        const rowsEl = (108          <div>109            {block.rows.map((m: MetricValue) => (110              <IndicatorRow key={m.indicator} metric={m} country={countryRef} regionName={regionShort(c.region) ?? c.region_name} series={seriesById.get(m.indicator)} eager={seriesById.has(m.indicator)} />111            ))}112          </div>113        );114        if (sectioned)115          return (116            <CollapsibleGroup key={block.subtopic} id={id} title={block.subtopic} count={block.rows.length} defaultOpen={i < OPEN_BLOCKS} summary={<TopicCompact rows={block.rows} country={countryRef} regionName={regionShort(c.region) ?? c.region_name} />}>117              {rowsEl}118            </CollapsibleGroup>119          );120        return (121          <Section key={block.subtopic} id={id} title={block.subtopic} level={3} tight className="pb-2 pt-6">122            {rowsEl}123          </Section>124        );125      })}126127      {noData.length ? (128        <details className="hairline group py-4">129          <summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 text-sm font-medium text-ink-2 hover:text-ink">130            <span className="inline-block transition-transform group-open:rotate-90">›</span>131            {t('topic.noData', { n: noData.length })}132          </summary>133          <p className="mt-1 text-xs text-ink-3">{t('topic.noDataHint', { country: countryRef.name })}</p>134          <ul className="mt-2 grid gap-x-6 sm:grid-cols-2 lg:grid-cols-3">135            {noData.map((m) => (136              <li key={m.indicator} className="flex justify-between gap-3 border-t border-rule py-2 text-sm">137                <Link href={routes.indicator(m.indicator)} className="link-quiet truncate text-ink-2">138                  {m.indicator_name ?? m.indicator}139                </Link>140                <span className="shrink-0 text-xs text-ink-3">{t('common.noData')}</span>141              </li>142            ))}143          </ul>144        </details>145      ) : null}146147      <Section id="other-topics" title={t('topic.otherTopics')} level={3} tight>148        <ul className="flex flex-wrap gap-1.5">149          {others.map((tp) => (150            <li key={tp.id}>151              <Link href={routes.countryTopic(countryRef.slug, tp.id)} className="inline-flex h-11 items-center rounded-sm border border-rule px-3 text-sm text-ink-2 hover:border-accent hover:text-accent md:h-9 md:px-2.5">152                {tp.short}153              </Link>154            </li>155          ))}156        </ul>157      </Section>158    </>159  );160}161